Skip to content

Fuzz the executor against a real Postgres, and record what it found - #122

Merged
jbylund merged 2 commits into
mainfrom
fuzz/differential-executor-vs-postgres
Aug 15, 2026
Merged

jbylund merged 2 commits into
mainfrom
fuzz/differential-executor-vs-postgres

Conversation

@jbylund

@jbylund jbylund commented Aug 14, 2026

Copy link
Copy Markdown
Owner

tests/test_sqlglot_workarounds.py was written by hand, one tripwire per bug somebody happened to trip over. That finds the bugs on the paths we walk. It says nothing about the paths we don't — and the two workarounds that silently outlived their cause (#50) are evidence that noticing is not a strategy.

So tools/fuzz generates random SELECTs, runs each against a real PostgreSQL and against the executor, and reports the divergences minimised to the smallest query that still shows them.

createdb pg_mimic_fuzz && python -m tools.fuzz --count 4000

Nothing here runs in CI. It needs a live server, and a suite that fails on a new random seed is a suite people delete. Its output is what runs in CI: fourteen new tripwires in this diff.

Four things it has to get right

Validity, not randomness, is the hard part. A generator that emits garbage spends its budget on syntax errors. generate.py is typed, and knows the handful of things Postgres rejects at runtime rather than at parse time: division and mod() by zero, mod() and two-argument round() on a double precision, a FULL JOIN on a non-hashable condition. About 1% of what it emits is still invalid and those samples are discarded — which is also what lets the shrinker be reckless. Postgres is the validity filter.

Grading, because Postgres and the executor disagree constantly in ways that are not bugs. avg(int) is a Decimal on one side and a float on the other; a comparison that calls that a failure buries the real findings under it. compare.py ranks COUNT/VALUE (always bugs) above EXACT/TYPE, and compares floats within a relative tolerance, since both engines do the same arithmetic in a different order and are allowed to land a few ULPs apart.

Shrinking, without which the tool is useless. 2817 raw failures on the first run were 212 buckets, each otherwise buried under forty lines of generated arithmetic. The moves are deliberately reckless — hoist any node to its own child, replace any node with a literal, drop any clause — because a variant Postgres refuses is rejected exactly like one that stops reproducing. Minimised queries then de-duplicate themselves, two seeds that found one bug converging on one text.

Knowing whose bug it is. The same executor is reachable either side of tables.py, so every finding reports whether it also fails raw sqlglot: also fails raw is upstream reaching our users, raw gets this right is one of our own rewrites breaking it.

What it found

Fourteen new upstream bugs, all still failing against sqlglot main at d138cae. Six are wrong answers rather than refusals, and three of those cannot be detected from the answer:

Plus CAST(0.5 AS INT) truncating where Postgres rounds (by the same two rules as round()), LIKE 'a\%b' ignoring the escape, EXISTS (subquery) emitting invalid Python, NULL IN (subquery) answering FALSE rather than NULL, and Decimal * float raising TypeError — that last needs no join, subquery or unusual function, just two numeric columns of different types, so it is the likeliest of these to be the first thing a user hits.

Details and the upstreaming split go on #58. Four findings are ours, not upstream — _rewrite_not_in turns a correct answer into a wrong one — and get their own issue rather than riding along here, since this diff adds no test for them.

Text collation is the one finding with no fix

The executor compares strings by code point and Postgres by its database collation, so 'abc' < 'ABC' disagrees on any locale-derived collation, and every ORDER BY divergence on the first run was that. There is no collation for the executor to consult, so this is a modelling gap: documented in docs/limitations.md, with the README noting createdb --locale=C to see past it. With C collation, 13 ordering buckets became 1 real bug.

Notes for review

  • docs/limitations.md is the only user-facing change; everything else is a dev tool and tests.
  • The tripwires are xfail(strict=True) like their neighbours, so the suite is green on the pinned floor and goes red the moment upstream fixes one.
  • tools/ is not packaged ([tool.hatch.build.targets.wheel] packages = ["pg_mimic"]), so none of this ships in the wheel.

tests/test_sqlglot_workarounds.py was written by hand, one tripwire per bug
somebody happened to trip over. That finds the bugs on the paths we walk. It
says nothing about the paths we don't, and the two workarounds that silently
outlived their cause (#50) are evidence that noticing is not a strategy.

So: tools/fuzz generates random SELECTs, runs each against a real PostgreSQL and
against the executor, and reports the divergences minimised to the smallest query
that still shows them.

Four things it has to get right, one module each.

Validity, not randomness, is the hard part -- a generator that emits garbage
spends its budget on syntax errors. generate.py is typed and knows the handful of
things Postgres rejects at runtime rather than at parse time (division by zero,
mod() on a double precision, a FULL JOIN on a non-hashable condition). About 1%
of what it emits is still invalid and those samples are discarded, which is also
what lets shrink.py propose reckless variants: Postgres is the validity filter.

Grading, because Postgres and the executor disagree constantly in ways that are
not bugs. avg(int) is a Decimal on one side and a float on the other; a
comparison that calls that a failure buries the real findings under it. compare.py
ranks COUNT and VALUE above EXACT and TYPE, and compares floats within a relative
tolerance since both engines do the same arithmetic in a different order.

Shrinking, without which the tool is useless: 2817 raw failures on the first run
were 212 buckets, each otherwise buried under forty lines of generated
arithmetic. Minimised queries de-duplicate themselves, two seeds that found one
bug converging on one text.

And knowing whose bug it is. The same executor is reachable either side of
tables.py, so every finding reports whether it also fails raw sqlglot. Four did
not: our own rewrites are what break them.

Fourteen new upstream bugs are tripwires here, all still failing against main at
d138cae. Six are wrong answers rather than refusals, and three of those cannot be
detected from the answer: count(DISTINCT x) ignores the DISTINCT, SELECT DISTINCT
a, a answers one column instead of two, and an outer join's NULL-padded row
survives a WHERE that tests the padded column.

Text collation is the one finding with no fix. The executor compares strings by
code point and Postgres by its database collation, so 'abc' < 'ABC' disagrees on
any locale-derived collation and every ORDER BY divergence on the first run was
that. It has no fix from outside -- there is no collation for the executor to
consult -- so it is documented in docs/limitations.md and the README says to
create the oracle with --locale=C to see past it.

Nothing here runs in CI. It needs a live server, and a suite that fails on a new
random seed is a suite people delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first version asserted count(DISTINCT a) over 1, 1, 2 -- which cannot tell
"ignores the DISTINCT" (count(a)) from "counts rows" (count(*)), because with no
NULL in the column those are the same number. With one, the executor answers 4,
so it is count(*): both the DISTINCT and the NULL-skipping are lost.

It is also not only count. sum(DISTINCT x) and avg(DISTINCT x) raise, because the
cause is one line of codegen -- the Python generator renders exp.Distinct as
set(), throwing the expression away, so COUNT(DISTINCT a) compiles to
COUNT(set()). That reclassifies the bug from planner-level to a single handler,
which is why #58 moves it up to a near-term PR.

Measured for the same reason: single-argument count(DISTINCT x) is SQL-92 and
agrees exactly across PostgreSQL 18.4, SQLite 3.51 and MySQL 8.0.46, and the
executor is wrong under every dialect rather than just postgres. So a fix cannot
break a dialect that wanted the old behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jbylund
jbylund merged commit 62f3427 into main Aug 15, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant